用 React hooks 實作一個 todo list


Posted by Christy on 2021-12-01

本文為實作 todo list 新增、刪除、標記完成/未完成、清空、篩選功能,用的是 React hooks,時空背景是在還沒學到 React Router 與 Redux 之前

基礎設定從這裡開始 來學 React 吧之一_以 todo list 為例學會 React 基礎與 useState 介紹

1. 基本設定

a. 建了另一個新的資料夾,裝好環境以後,記得要先安裝 styled-components 才可以用。

$npm install --save styled-components

b. 想要用 ESlint prettier 要先安裝 $npm install --save-dev prettier

這裡的錯誤訊息會是:Cannot find module 'prettier'

先安裝好以後,如果還有錯誤訊息,可以先參考 ESlint prettier 在 VSCode 的指南,把該裝的都裝一裝,接著重開 VSCode,應該會有用。

2. 實作相關

a. 遇到的問題

好吧我暫時把檔案整理好了,把 component 包起來,讓我的檔案有點混亂,但是先這樣吧

a.1 當我輸入 todo 並按下新增時,輸入框沒有自動清空,因為我的 input 的 component 沒有傳 props value={value}

a.2 當我按下 delete button 時,沒有刪除 todo,原因是 btn 要 onClick 而不是傳 props

a.3 忘記 props 的 style 怎麼寫了

  const TodoContent = styled.div`
    ${(props) =>
      props.$isDone &&
      `
      text-decoration: line-through;
    `}
  `;

a.4 todo list 的篩選功能怎麼做?

一開始按照之前的邏輯實作,發現按了完成再按已完成,篩選器就壞了,但是網路上大部分的參考都是用 redux 做的,因此參考了老師及同學的範例

重點在於篩選要產生一個新的 state,而不是去改原本的 todos

要先有 [filter, setFilter] = useState();

概念可以參考 W21 + W22_React 學習過程筆記 提到的 W22 隨意聊內容

// 關鍵程式碼
{todos
  .filter((todo) => {
    if (filter === "all") return todo;
    return filter === "done" ? todo.isDone : !todo.isDone;
  })
  .map((todo) => (
    <TodoItem
      key={todo.id}
      handleDeleteTodo={handleDeleteTodo}
      handleTodoIsDone={handleTodoIsDone}
      todo={todo}
    />
))}

a.5 所有程式碼

分成 index.js, App.js, TodoContainer.js, TodoItem.js

TodoContainer 是整個 todo,主要寫邏輯用的;TodoItem把 todo 的內容獨立出來

// index.js

import ReactDOM from "react-dom";
import App from "./App";

ReactDOM.render(<App />, document.getElementById("root"));
// App.js
import TodoContainer from "./components/TodoContainer";

export default function App() {
  return (
    <div className="App">
      <TodoContainer />
    </div>
  );
}
// TodoContainer.js
import React, { useState, useRef, useCallback } from "react";
import TodoItem from "./TodoItem";
import styled from "styled-components";

const TodoWrapper = styled.div`
  font-family: "ubuntu";
  margin: 30px auto;
  width: 480px;
  border: 3px solid #f5f5f5;
  border-radius: 5px;
  padding: 30px;
  text-align: center;
  box-shadow: 3px 3px 5px #ccc;
`;

const Title = styled.h1`
  color: #28262c;
  font-size: 48px;
`;

const CreateTodo = styled.div`
  margin: 20px auto;
`;

const TodoInput = styled.input`
  margin-right: 10px;
  width: 300px;
  height: 24px;
  border: 1px solid #ccc;
  border-radius: 5px;
  box-shadow: 1px 1px 3px #ccc;
`;

const AddButton = styled.button`
  font-family: "ubuntu";
  width: 80px;
  background: black;
  color: white;
  border-radius: 3px;
  box-shadow: 1px 1px 3px #666;
  border: none;
  padding: 5px;
`;

const SelectTodo = styled.div``;

const AllButton = styled.button`
  font-family: "ubuntu";
  width: 80px;
  background: #39393a;
  color: white;
  border-radius: 3px;
  box-shadow: 1px 1px 3px #666;
  border: none;
  padding: 5px;
`;

const ActiveButton = styled.button`
  font-family: "ubuntu";
  width: 80px;
  background: #ffe74c;
  color: #333;
  border-radius: 3px;
  box-shadow: 1px 1px 3px #666;
  border: none;
  padding: 5px;
  margin: 0 10px;
`;

const CompletedButton = styled.button`
  font-family: "ubuntu";
  width: 80px;
  background: #666;
  color: white;
  border-radius: 3px;
  box-shadow: 1px 1px 3px #666;
  border: none;
  padding: 5px;
`;

const TodoList = styled.div`
  margin-top: 10px;
`;

const ClearTodo = styled.button`
  font-family: "ubuntu";
  width: 120px;
  background: #c8553d;
  color: white;
  border-radius: 3px;
  box-shadow: 1px 1px 3px #666;
  border: none;
  padding: 5px;
  margin: 10px;
`;

export default function TodoContainer() {
  const id = useRef(1);
  const [todos, setTodos] = useState([]);
  const [value, setValue] = useState("");
  const [filter, setFilter] = useState("all");

  const handleAddTodo = useCallback(() => {
    if (!value) return alert("wanna type something?");
    setTodos([{ id: id.current, content: value }, ...todos]);
    setValue("");
    id.current++;
  }, [todos, value]);

  const handleInputChange = useCallback((e) => {
    setValue(e.target.value);
  }, []);

  const handleDeleteTodo = useCallback(
    (id) => {
      setTodos(todos.filter((todo) => todo.id !== id));
    },
    [todos]
  );

  const handleTodoIsDone = useCallback(
    (id) => {
      setTodos(
        todos.map((todo) => {
          if (todo.id !== id) return todo;
          return {
            ...todo,
            isDone: !todo.isDone,
          };
        })
      );
    },
    [todos]
  );

  const handleTodoClear = useCallback(() => {
    setTodos(todos.filter((todo) => todo.isDone !== true));
  }, [todos]);

  const filterAll = useCallback(() => {
    setFilter("all");
  }, []);

  const filterDone = useCallback(() => {
    setFilter("done");
  }, []);

  const filterUndone = useCallback(() => {
    setFilter("undone");
  }, []);

  return (
    <TodoWrapper>
      <Title>Todo List</Title>
      <CreateTodo>
        <TodoInput value={value} onChange={handleInputChange}></TodoInput>
        <AddButton onClick={handleAddTodo}>Add Todo</AddButton>
      </CreateTodo>
      <SelectTodo>
        <AllButton onClick={filterAll}>All</AllButton>
        <ActiveButton onClick={filterUndone}>Active</ActiveButton>
        <CompletedButton onClick={filterDone}>Completed</CompletedButton>
      </SelectTodo>
      <TodoList>
        {todos
          .filter((todo) => {
            if (filter === "all") return todo;
            return filter === "done" ? todo.isDone : !todo.isDone;
          })
          .map((todo) => (
            <TodoItem
              key={todo.id}
              handleDeleteTodo={handleDeleteTodo}
              handleTodoIsDone={handleTodoIsDone}
              todo={todo}
            />
          ))}
      </TodoList>
      <ClearTodo onClick={handleTodoClear}>Clear Completed</ClearTodo>
    </TodoWrapper>
  );
}
// TodoItem.js
import styled from "styled-components";
import { memo } from "react";

const Todo = styled.div`
  border: 1px solid #ccc;
  & + & {
    margin-top: 10px;
  }
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 10px;
  border-radius: 5px;
  box-shadow: 1px 1px 3px #ccc;
`;

const TodoContent = styled.div`
  ${(props) =>
    props.$isDone &&
    `
    text-decoration: line-through;
  `}
`;

const DeleteButton = styled.button`
  font-family: "ubuntu";
  margin-right: 10px;
  width: 80px;
  background: #c8553d;
  color: white;
  border-radius: 3px;
  border: none;
  box-shadow: 1px 1px 3px #666;
  padding: 5px;
`;

const CheckButton = styled.button`
  font-family: "ubuntu";
  width: 80px;
  background: #73a6ad;
  color: white;
  border-radius: 3px;
  border: none;
  box-shadow: 1px 1px 3px #666;
  padding: 5px;
`;

const ButtonWrapper = styled.div`
  display: flex;
  justify-content: space-between;
`;

function TodoItem({ todo, handleDeleteTodo, handleTodoIsDone }) {
  const handleDeleteClick = () => {
    handleDeleteTodo(todo.id);
  };

  const handleIsDoneClick = () => {
    handleTodoIsDone(todo.id);
  };

  return (
    <Todo data-todo-id={todo.id}>
      <TodoContent $isDone={todo.isDone}>{todo.content}</TodoContent>
      <ButtonWrapper>
        <DeleteButton onClick={handleDeleteClick}>Delete</DeleteButton>
        <CheckButton onClick={handleIsDoneClick}>
          {todo.isDone ? "Undone" : "Done"}
        </CheckButton>
      </ButtonWrapper>
    </Todo>
  );
}

export default memo(TodoItem);









Related Posts

3. java 17  範例目錄

3. java 17 範例目錄

實作餐廳網站 FAQ 頁面

實作餐廳網站 FAQ 頁面

JS 展開  (Spread Operator) 以及反向展開 (Rest Parameters)

JS 展開 (Spread Operator) 以及反向展開 (Rest Parameters)


Comments